iT邦幫忙

2024 iThome 鐵人賽

DAY 5
0
Modern Web

Svelte 的奇妙冒險系列 第 5

[Svelte 的奇妙冒險] Day 05 - $effect 的基本用法

  • 分享至 

  • xImage
  •  

$effect

在 Svelte 中我們要管理 side-effect 的行為通常就會使用 $effect 這個 rune ,首先我們先來看一個簡單的範例

先做個名詞定義,以下提到 effect 通常是指要管理 side effect 也就是 $effect 裡的 ()⇒{} 這個 function 裡面的東西,而 $effect 指的是這個 rune

<script lang="ts">
	let obj = $state({ value: 0 });

	let derivedObj = $derived({ value: obj.value * 2 });

	$effect(() => {
		console.log(`[Effect 1] obj.value: ${obj.value}`);
	});

	$effect(() => {
		console.log(`[Effect 2] obj: ${obj}`);
	});

	$effect(() => {
		console.log(`[Effect 3] derivedObj: ${derivedObj}`);
	});
</script>

<button onclick={() => (obj.value += 1)}> Increment obj.value </button>
<button onclick={() => (obj = {...obj,value: obj.value + 1})}> Increment obj.value (immutable)</button>

<p class="content">{obj.value} doubled is {derivedObj.value}</p>

從上面的影片我們可以看出四個小細節

  1. $effect 在第一次渲染時會先執行一次

  2. $effect 會自動追蹤依賴,有使用到的 $state 或者 $derived 更新時會再次執行

  3. 如果是變更其中一個 property 的話,對於${obj} 這種使用整個 object 的形式並不會有 reactive

  4. 如果是 immutable update 因為是整個 object 被替換掉所以 [Effect 2] 可以被觸發到

cleanup

這裡為了方便示範,先把倒數計時的部分抽成一個 component,而在 $effectreturn 的 function 就是所謂的 cleanup function。

執行的時機為下次 effect 執行前會先執行一次 cleanup 以及 component destroy (unmounted) 前會先執行。

<!-- in Counterdown.svelte  -->
<script lang="ts">
	let count = $state(0);

	$effect(() => {
		console.log('Starting interval');
		const id = setInterval(() => {
			count += 1;
		}, 1000);
		return () => {
			console.log('Clearing interval');
			clearInterval(id);
		};
	});
</script>

{count}

再次說明 Svelte 中每個 .svelte 的檔案都是一個 component

<!-- in +page.svelte  -->
<script lang="ts">
	import Countdown from './Countdown.svelte';
  	let showCountdown = $state(false);
</script>

<div>
	<button onclick={() => (showCountdown = !showCountdown)}>
		{showCountdown ? 'Hide' : 'Show'} Countdown
	</button>
</div>
{#if showCountdown}
	<Countdown />
{/if}

而要使用 component 就是 import 該 .svelte 的 default export ,至於其他關於 component 的細節就留待以後文章再來說明。

從影片中看得出來,第一次 render 時會有 Starting interval 而當我按下按鈕後就會將 <Countdown /> destroy 也就觸發了 cleanup function

有些讀者或許會想問為什麼 console.log('Starting interval'); 沒有每秒都被執行,回到前面例子的我們提到的「$effect 會自動追蹤依賴,有使用到的 $state 或者 $derived 更新時會再次執行」。

但在這個例子中因為我沒有在 $effect 的 function 中直接使用到 count 而是只有在 setInterval 的 callback 中使用,所以並不會被 $effect 自動追蹤到依賴。

$effect.pre

在 Svelte 我們是能將 effect 的執行時機提前在 DOM 更新前的,就是使用 $effect.pre 這個 rune。

<!-- in Counter.svetle -->
<script lang="ts">
	let obj = $state({ value: 0 });

	let derivedObj = $derived({ value: obj.value * 2 });
	let p: HTMLParagraphElement| null = $state(null);

	$effect.pre(() => {
		console.log(
			'\x1b[36m%s\x1b[0m',
			`[Pre Effect]\n`,
			`p.innerText: ${p?.innerText} \n obj.value: ${obj.value}`
		);
		return () => {
			console.log(
				'\x1b[36m%s\x1b[0m',
				`[Pre Effect Cleanup]\n`,
				`p.innerText: ${p?.innerText} \n obj.value: ${obj.value}`
			);
		};
	});

	$effect(() => {
		console.log('\x1b[32m%s\x1b[0m', `[Effect 1]\n`, `obj.value: ${obj.value}`);
		return () => {
			console.log('\x1b[32m%s\x1b[0m', `[Effect 1 Cleanup]\n`, `obj.value: ${obj.value}`);
		};
	});
</script>

<button onclick={() => (obj.value += 1)}> Increment obj.value </button>
<button
	onclick={() =>
		(obj = {
			...obj,
			value: obj.value + 1
		})}
>
	Increment obj.value (immutable)</button
>

<p class="content" bind:this={p}>{obj.value} doubled is {derivedObj.value}</p>

'\x1b[36m%s\x1b[0m' 之類的東西是將 console 的字上色用的

這邊我們先用bind:this 這個 directives 來輔助說明,簡單來說它的功用就是可以獲得 DOM node 。

<!-- in +page.svelte  -->
<script lang="ts">
	import Counter from './Counter.svelte';

	let showCounter = $state(true);
</script>

<div>
	<button onclick={() => (showCounter = !showCounter)}>
		{showCounter ? 'Hide' : 'Show'} Counter
	</button>
</div>
{#if showCounter}
	<Counter />
{/if}

我們可以發現重新整理後會先出現 [Pre Effect] 然後才是 [Effect 1] ,這是因為 $effect.pre 是會在 DOM 更新前先行觸發,而此刻 <p> 還沒出現在 DOM 上,所以 p.innerText 會是 undefinedobj.value0 是因為這個 state 的預設值是 0

[Pre Effect] 
p.innerText: undefined 
obj.value: 0

然後 DOM 更新完後 $effect 觸發了

[Effect 1] 
p.innerText: 0 doubled is 0 
obj.value: 0

因為對於 $effect.pre 來說 p?.innerTextundefined 變成有值了所以會再次觸發 $effect.pre ,但在執行 $effect.pre 的 effect 前會先執行它的 cleanup。

[Pre Effect Cleanup]
p.innerText: 0 doubled is 0 
obj.value: 1

[Pre Effect]
p.innerText: 0 doubled is 0 
obj.value: 1

所以初次渲染最後順序才會是 [Pre Effect][Effect 1][Pre Effect Cleanup][Pre Effect]

接下來按了按鈕狀態更新後,在因為 obj.value 更新了所以 DOM 更新前會先執行 $effect.pre

[Pre Effect Cleanup]
p.innerText: 0 doubled is 0 
obj.value: 1

[Pre Effect]
p.innerText: 0 doubled is 0 
obj.value: 1

每次 effect 執行前會先執行它的 cleanup

之後 DOM 更新完成了,就開始執行 $effect

[Effect 1 Cleanup]
p.innerText: 1 doubled is 2 
obj.value: 1

[Effect 1]
p.innerText: 1 doubled is 2 
obj.value: 1

然後將 Counter destroy 就觸發了 $effect 以及 $effect.pre 的 cleanup

[Pre Effect Cleanup]
 p.innerText: 1 doubled is 2 
 obj.value: 1

[Effect 1 Cleanup]
 p.innerText: 1 doubled is 2 
 obj.value: 1

之後再 mount 一次就會跟第一次掛載的順序一樣。

小結

  1. $effect 它是在 component 在 DOM 上渲染後執行,並會在使用到 $state$derived 的值更新後再次重新執行

  2. $effect.pre 也是在使用到 $state$derived 的值更新後重新執行,只是執行時機是在 DOM 渲染前

  3. cleanup 是會在每次 effect 執行前先執行以及會在 destroy (unmount) 時執行

雖然不是每個狀態的變更都會重新渲染 DOM ,所以$effect$effect.pre 用 DOM 渲染前後作為分界點只是為了好想像。


明天將會繼續說明 $effect 的其他行為以及把 rune 這個篇章做個收尾,原本以為 rune 是一天就講得完,但深入研究一下才發現 $effect 比想像中的複雜很多QQ

參考資料

  1. https://svelte.dev/docs/element-directives#bind-this
  2. https://svelte-5-preview.vercel.app/docs/runes
  3. https://sveltekit.io/blog/svelte-5#the-untrack-function

source code

https://github.com/toddLiao469469/30days-for-svelte5/tree/main/src/routes/day05


上一篇
[Svelte 的奇妙冒險] Day 04 - $state 與 $derived
下一篇
[Svelte 的奇妙冒險] Day 06 - 深入 $effect
系列文
Svelte 的奇妙冒險30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
kevin93203
iT邦新手 5 級 ‧ 2024-11-09 16:51:30

前言:

自己是前端小白,
原本side project用Vanilla JS寫,結果隨著功能越加越多,程式碼相當繁瑣,已經難以trace code,這幾天驚覺是時候使用框架了,但是又不想用React來寫,本來是先想到Vue,但是稍微看了一下官方文檔,實在不太喜歡Vue把if、else、for這些邏輯寫在element的attribute裡,所以就決定試試看Svelte,查了發現最近正式release了第5版,我大概把官方tutorial的Basic Svelte做了一遍,覺得寫法比React簡潔許多,還有雙向綁定,而且CSS的作用只會侷限在該Component裡實在太方便,解決了樣式汙染和一定程度減少class的命名焦慮,但是Svelte的state和effect怎樣可以reactive我還沒有搞得很清楚,如果又搭配global的shared state、store和子元件變更props的值,整個行為又更複雜,加上我又是Svelte超級小白,搞的我頭昏腦花,不像React就是單向數據流,要更改state就用set function,子元件要更改父元件的state就將set function傳遞給子元件就好,只要更改state,甚至是state裡面的任何一個property,基本上就是整個component連同他的所有子孫全部重新執行和渲染,行為和數據流單純,不太需要思考數據同步的問題,effect的話我自己也比較偏好React要表明dependency,可以一眼知道這個effect執行的依賴條件,而且也比較容易控制和調整,Svelte給Compiler自己判定dependency雖然看起來好像很方便,也能用untrack來表明不要依賴此變數,但如果effect裡面邏輯很複雜有許多變數的話,且又難以馬上辨別出哪些是state、derived或props,感覺就會變成是一個災難。

以上都是我短短幾天使用Svelte的個人感想和心得,有些問題可能是因為我還不熟悉所以才有的,也因為用Svelte時遇到了這些問題,才開始找外部或社群有沒有相關的問題和教學,沒想到鐵人賽馬上就有,而且還很完整詳細,目前正在學習中,真是感恩!


以下正題:

這段程式碼應該不小心誤植

<button onclick={() => (obj.value += 1)}> Increment obj.value (immutable)</button>

應該改為下段程式碼才是immutable

<button onclick={() => (obj = { ...obj, value: obj.value + 1 })}> Increment obj.value (immutable) </button>

意外發現,如果effect使用JSON.stringify(obj)Object.keys(obj),就算只修改property,都會觸發effect,然後getValue(obj)表面上是傳入整個obj,但function內部會拜訪obj.value,所以如果有變更到value的話一樣會觸發effect,還有如果連續按set obj.count = 0,會發現只有第一次有觸發effect,感覺詳細的effect觸發的機制好像可以整理成以下:

object

  1. 如果直接訪問整個object,則當這個object被重新賦值的時候就會觸發
  2. 如果訪問到object裡面的property,則當property的value被變更且與原來值不一樣或是object直接被整個重新賦值就會觸發 (PS: 像JSON.stringify(obj)Object.keys(obj)因為會訪問所有property,所以只要任意property的value發生變化,就會觸發)
  3. 其餘狀況則不觸發

array

  1. 如果訪問整個array,則array被重新賦值或是任意element的值發生變化,都會觸發
  2. 如果訪問array的某個element(ex: array[0]),則當該element的值發生變化或是整個array被重新賦值時會觸發
  3. 其餘狀況則不觸發

感想: Svelte effect 觸發機制真的很複雜,而且還有nested的object和multidimesional arrays,甚至是更多層的混搭 ...

程式碼如下

<script>
    let obj = $state({ value: 0 });
		let array = $state([0])

		function getValue(obj){
			return obj.value
		}

		$effect(() => {
        console.log(`[Effect 1] obj: ${obj}`);
    });
	
    $effect(() => {
        console.log(`[Effect 2] obj: ${JSON.stringify(obj)}`);
    });
		$effect(() => {
        console.log(`[Effect 3] obj: ${Object.keys(obj)}`);
    });
		$effect(() => {
        console.log(`[Effect 4] obj: ${getValue(obj)}`);
    });

		$effect(() => {
        console.log(`[Effect 5] array: ${array}`);
    });

		$effect(() => {
        console.log(`[Effect 6] array: ${array[0]}`);
    });

		$effect(() => {
	        console.log(`[Effect 7] array: ${array[1]}`);
	  });
	
</script>

<p>{JSON.stringify(obj)}</p>
<p>{array.join(", ")}</p>
<button onclick={() => {obj.value += 1}}> Increment obj.value </button>
<button onclick={() => {obj.count = 0}}> set obj.count = 0 </button>
<button onclick={() => {obj = { value: 0 }}}> reset obj </button>
<button onclick={() => {array.push(array.length)}}> push array new element </button>
<button onclick={() => array[0]++ }> increase array[0]</button>
<button onclick={() => array[1]++ }> increase array[1]</button>
<button onclick={() => array = [0]}> reset array</button>

非常感謝您勘誤~


$effect 的機制確實有點複雜,但我想只要知道這個 effect 是用到該 state 的哪些部分應該就比較能夠了解整個流程了。

這點其實雖然說很方便但其實不太好預測結果,因為直覺來說(至少寫習慣的 React 的人的直覺)都會想說 object 直接更新特定 property 並不會改變 object 本身的 reference ,所以並不會觸發更新。

但我覺得大多數情況下開發者不必太在意這點,畢竟有用到的 property 部分通常都會是我們想要監聽的 property,只是 Svetle 多幫我們做到監聽 property 更新

至於 $effect 如果有複雜的邏輯會不會很難維護這件事情,我覺得或許一開始就不要有 $effect 可能會比較好。

至少在我自己的開發經驗,不管是 $effect 或是 React 的 useEffect 大部分時間可能都可以被取代,可能是在某些事件 callback中執行原本想要執行的 effect 或者用 derived 就能取代某些狀態同步的功能

我要留言

立即登入留言